Delayed Shutter Camera
What Does It Do?
This autonomous camera system captures images at three different time delays—30 seconds, 1 hour, and 24 hours—triggered by physical buttons. Each image is automatically downloaded to a computer and displayed on a minimal web gallery that updates in real-time.
Three main components:
- ESP32-S3 Camera on Custom PCB: Takes scheduled photos and serves them over WiFi
- Python Download Script: Automatically monitors and downloads new images
- Web Gallery: Minimal interface displaying all captured images with timestamps
System Capabilities:
- Up to 10 concurrent scheduled captures
- 800x600 JPEG images (~30-50KB each)
- 5-7 second download latency
- Unlimited storage on computer
- Public web access via ngrok
Project Evolution
Initial Concept: Camera in a Rock
The original vision was a camera hidden inside a decorative rock that would capture one photo every 24 hours. The goal was to create an inconspicuous desk ornament that secretly documented daily life, with images saved to an SD card for later discovery.
First Pivot: Periscope Mechanism
I wanted the camera to periscope up from the rock to take photos, adding mechanical interest and surprise. However, conceptualizing and building a reliable servo motor mechanism within the space constraints proved too challenging within the project timeline.
Final Design: Three Delayed Shutters
The final design embraces simplicity: three buttons triggering captures at different time delays. This proved more reliable, built on previous weeks' work better, and opened interesting use cases—from time-lapse photography to capturing spontaneous moments after a waiting period.
Power Evolution: From Battery to USB
Initially planned as battery-powered for true portability. However, I encountered persistent soldering issues—wires wouldn't stick to battery terminals. After consulting with Jake, I discovered the cause: generic header pin wires from the shop were enamel-coated, preventing proper solder adhesion. Jake suggested harvesting wires from stepper motors instead.
By this point, I had already redesigned for USB power, which ultimately simplified deployment and removed battery life concerns during extended time-lapse sessions.
Key Lesson: Wire quality matters immensely. Enamel coating isn't always visible, but it completely prevents solder adhesion. Use proper hookup wire or harvest from stepper motors for reliable connections.
What Was Designed
1. Custom PCB Breakout Board
Designed for the Xiao ESP32S3 featuring:
- Three tactile SMD push buttons (D0, D1, D2)
- Orange LED with 1000Ω current-limiting resistor (D9)
- Male pin headers for Xiao mounting
- 16mil trace width for reliable connections
- Compact form factor for rock enclosure
LED Selection Rationale: Orange LED was chosen because it operates comfortably at 3.3V. White LEDs require driver circuits, blue LEDs are extremely bright at 3.3V. Orange provides good visibility without being aggressive.
Resistor Calculation: 1000Ω for orange LED at 3.3V: (3.3V - 2.0V) / 0.001A = 1300Ω. Using 1000Ω provides ~1.3mA for better brightness while staying within LED specs.
PCB Design Journey: 12+ Iterations
Phase 1: Fusion 360 Attempts
- Through-holes appeared in PNG exports but wouldn't generate toolpaths
- Switched to KiCad for better component support
Phase 2: KiCad Learning Curve
- Through-hole issues persisted—holes too tight for Xiao pins
- Other through-holes in same board were correct, suggesting component-specific issues
- Inverting PNG for edge cuts: holes generated but tabs inverted
Phase 3: Mods & Milling Refinement
- Critical fix: Changed tool diameter from 0.80mm to 0.75mm—toolpaths finally generated correctly
- Discovered both available endmills were broken, causing hairy cuts
- Board bowing solved with double-sided tape near edges (small PCB advantage)
Component Sizing Lessons:
- LED and resistor pads slightly too small—workable but challenging
- 16mil trace width provided excellent reliability buffer
- SMD button pads appropriately sized and easy to solder
2. 3D Printed Case
Originally planned to embed in a real rock. When rock milling proved problematic (see rock milling documentation), I pivoted to a rock-shaped case designed with Harrison's help.
Design features:
- Two-part case fitting together via snap mechanism
- Internal cavity for PCB mounting
- Cable routing channel for USB
Issues encountered:
- Mesh error created protruding piece where microcontroller should sit
- Cable channel too narrow for USB-C connector
- Snap mechanism worked excellently—proper sizing achieved
Download Case STL File
3. ESP32 Firmware
Arduino C++ implementation featuring:
- Camera configuration: SVGA (800x600), JPEG quality 10
- Circular buffer for up to 10 concurrent scheduled captures
- WiFi web server with REST API endpoints
- NTP time synchronization for accurate timestamps
- LED feedback system (300ms blink for button, solid during capture)
4. Python Download System
Flask-based server application:
- Background polling: checks ESP32 every 5 seconds
- Image ID tracking prevents duplicate downloads
- Structured filename: Button#_delay_YYYY-MM-DD_HH-MM-SS.jpg
- Web server on port 8765 (configurable)
- Auto-refresh gallery every 10 seconds
5. Web Gallery Interface
Minimal, library catalog-inspired design:
- White background with grey text (no visual noise)
- Three images per row with 20px spacing
- Captions: timestamp and delay duration only
- No borders, buttons, or decorative elements
- Responsive layout for mobile viewing
Bill of Materials
Ideal Scenario (Everything Works First Try)
| Component |
Qty |
Source |
Unit Cost |
Total |
| Xiao ESP32S3 Sense |
1 |
Seeed Studio / Amazon |
$13.99 |
$13.99 |
| FR-1 Copper Board (4"×6") |
1 |
Amazon / Digikey |
$8.00 |
$8.00 |
| SMD Push Buttons |
3 |
Digikey / Lab stock |
$0.15 |
$0.45 |
| Orange LED (0805) |
1 |
Digikey / Lab stock |
$0.10 |
$0.10 |
| 1000Ω Resistor (0805) |
1 |
Digikey / Lab stock |
$0.10 |
$0.10 |
| Male Pin Headers (40-pin) |
1 |
Amazon / Lab stock |
$0.50 |
$0.50 |
| Ideal Total |
|
|
|
$23.14 |
Actual Cost (Reality of Prototyping)
| Component |
Qty |
Why Extra Needed |
Unit Cost |
Total |
| Xiao ESP32S3 Sense |
2 |
Testing + final assembly |
$13.99 |
$27.98 |
| FR-1 Copper Board |
1 |
Small boards fit multiple attempts |
$8.00 |
$8.00 |
| SMD Push Buttons |
3 |
Final design only |
$0.15 |
$0.45 |
| Orange LED (0805) |
3 |
Testing, burned one, final |
$0.10 |
$0.30 |
| 1000Ω Resistor (0805) |
3 |
Lost one, testing, final |
$0.10 |
$0.30 |
| Male Pin Headers |
2 |
Multiple board attempts |
$0.50 |
$1.00 |
| Actual Total |
|
|
|
$38.03 |
Cost Reality: Actual cost was $14.89 more than ideal due to:
- Backup Xiao for testing separate from final assembly
- Extra SMD components for losses and testing
- Small board size allowed many iterations on one copper sheet
Budget Recommendation: Plan for $35-40 for first PCB project with one revision expected.
Fabrication Process
PCB Manufacturing
Tools & Software
- Design: KiCad (PCB layout)
- Case Design: Fusion 360 & Rhino
- Toolpaths: Mods
- Milling: Carvera CNC mill
Critical Milling Parameters
Traces: 0.75mm flat endmill
Outline: 1/32" endmill
Export: 1000 DPI monochrome PNG
Tool offset: 4 passes at 50% overlap
Common Problems & Solutions
| Problem |
Cause |
Solution |
| Hairy, imprecise cuts |
Broken/dull endmill |
Replace endmill, check condition before starting |
| Board lifting during cut |
Insufficient adhesion |
Double-sided tape near all edges, small boards work best |
| Through-holes won't generate |
Tool diameter mismatch |
Adjust from 0.80mm to 0.75mm in mods |
| Traces too thin |
Export DPI too low |
Use 1000 DPI minimum for clean traces |
Download Complete Toolpaths (.nc file)
Component Assembly
Soldering Sequence
- SMD components first: LED, resistor, push buttons
- Pin headers: Align carefully, solder one pin, check alignment, complete
- Xiao module: Socket onto headers, solder in place
- Testing: Upload test code before camera connection
Soldering Tips
- Use tip tinner: Game-changer for speed and quality
- SMD technique: Tack one pad, align component, complete other side
- Heat management: Quick touches—excessive heat rips pads
- Wire quality: Use proper hookup wire, not enamel-coated header pins
Key Discovery: Tip tinner made soldering dramatically faster and cleaner. Highly recommended for any SMD work. Saves time and reduces cold joints.
Complete Code & Configuration
Part 1: ESP32 Firmware
Arduino IDE Settings (CRITICAL):
- Board: XIAO_ESP32S3
- PSRAM: OPI PSRAM (camera won't work without this!)
- Upload Speed: 921600 (or 115200 if fails)
- USB CDC On Boot: Enabled
Before uploading: Update WiFi credentials in lines 7-8:
const char* ssid = "YOUR_NETWORK_NAME";
const char* password = "YOUR_PASSWORD";
Click to view complete ESP32 code (350 lines)
#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
#include <time.h>
// WiFi credentials - CHANGE THESE
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
// Button pins
const int BUTTON_1 = D0; // 30 seconds
const int BUTTON_2 = D1; // 1 hour
const int BUTTON_3 = D2; // 24 hours
const int LED_PIN = D9;
WebServer server(80);
struct ImageData {
uint8_t* buffer;
size_t length;
String timestamp;
String buttonName;
String delayInfo;
bool available;
int imageID;
} latestImage;
int totalImagesCaptured = 0;
struct ScheduledCapture {
bool active;
unsigned long captureTime;
String buttonName;
String delayName;
};
#define MAX_SCHEDULED 10
ScheduledCapture scheduled[MAX_SCHEDULED];
bool button1LastState = HIGH;
bool button2LastState = HIGH;
bool button3LastState = HIGH;
// Camera pins for Xiao ESP32S3 Sense
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 10
#define SIOD_GPIO_NUM 40
#define SIOC_GPIO_NUM 39
#define Y9_GPIO_NUM 48
#define Y8_GPIO_NUM 11
#define Y7_GPIO_NUM 12
#define Y6_GPIO_NUM 14
#define Y5_GPIO_NUM 16
#define Y4_GPIO_NUM 18
#define Y3_GPIO_NUM 17
#define Y2_GPIO_NUM 15
#define VSYNC_GPIO_NUM 38
#define HREF_GPIO_NUM 47
#define PCLK_GPIO_NUM 13
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n\n=== ESP32 Delayed Shutter Camera ===");
latestImage.buffer = NULL;
latestImage.available = false;
latestImage.imageID = 0;
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, HIGH); // OFF (inverted logic)
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
pinMode(BUTTON_1, INPUT_PULLUP);
pinMode(BUTTON_2, INPUT_PULLUP);
pinMode(BUTTON_3, INPUT_PULLUP);
for(int i = 0; i < MAX_SCHEDULED; i++) {
scheduled[i].active = false;
}
Serial.println("Initializing camera...");
if(!initCamera()) {
Serial.println("Camera init failed!");
while(1) {
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
delay(200);
}
}
Serial.println("Camera OK!");
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.println("========================================");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
Serial.println("========================================");
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
setenv("TZ", "EST5EDT,M3.2.0,M11.1.0", 1);
tzset();
server.on("/", handleRoot);
server.on("/status", handleStatus);
server.on("/latest", handleLatest);
server.on("/download", handleDownload);
server.begin();
Serial.println("Ready!\n");
}
void loop() {
server.handleClient();
for(int i = 0; i < MAX_SCHEDULED; i++) {
if(scheduled[i].active && millis() >= scheduled[i].captureTime) {
digitalWrite(LED_PIN, HIGH);
captureImage(scheduled[i].buttonName, scheduled[i].delayName);
delay(1000);
digitalWrite(LED_PIN, LOW);
scheduled[i].active = false;
}
}
bool button1State = digitalRead(BUTTON_1);
bool button2State = digitalRead(BUTTON_2);
bool button3State = digitalRead(BUTTON_3);
if(button1LastState == HIGH && button1State == LOW) {
scheduleCapture(30000, "Button1_30sec", "30 seconds");
}
button1LastState = button1State;
if(button2LastState == HIGH && button2State == LOW) {
scheduleCapture(3600000, "Button2_1hr", "1 hour");
}
button2LastState = button2State;
if(button3LastState == HIGH && button3State == LOW) {
scheduleCapture(86400000, "Button3_24hr", "24 hours");
}
button3LastState = button3State;
delay(50);
}
void scheduleCapture(unsigned long delayMs, String buttonName, String delayName) {
int slot = -1;
for(int i = 0; i < MAX_SCHEDULED; i++) {
if(!scheduled[i].active) {
slot = i;
break;
}
}
if(slot == -1) {
Serial.println("Too many scheduled!");
for(int i = 0; i < 3; i++) {
digitalWrite(LED_PIN, HIGH);
delay(100);
digitalWrite(LED_PIN, LOW);
delay(100);
}
return;
}
scheduled[slot].active = true;
scheduled[slot].captureTime = millis() + delayMs;
scheduled[slot].buttonName = buttonName;
scheduled[slot].delayName = delayName;
digitalWrite(LED_PIN, HIGH);
Serial.println("\n>>> " + buttonName + " SCHEDULED <<<");
delay(300);
digitalWrite(LED_PIN, LOW);
}
bool initCamera() {
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sscb_sda = SIOD_GPIO_NUM;
config.pin_sscb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.pixel_format = PIXFORMAT_JPEG;
config.frame_size = FRAMESIZE_SVGA;
config.jpeg_quality = 10;
config.fb_count = 1;
return (esp_camera_init(&config) == ESP_OK);
}
void captureImage(String buttonName, String delayInfo) {
camera_fb_t * fb = esp_camera_fb_get();
if(!fb) return;
if(latestImage.buffer != NULL) free(latestImage.buffer);
latestImage.buffer = (uint8_t*)malloc(fb->len);
if(latestImage.buffer != NULL) {
memcpy(latestImage.buffer, fb->buf, fb->len);
latestImage.length = fb->len;
latestImage.timestamp = getTimestamp();
latestImage.buttonName = buttonName;
latestImage.delayInfo = delayInfo;
latestImage.available = true;
latestImage.imageID++;
totalImagesCaptured++;
}
esp_camera_fb_return(fb);
}
String getTimestamp() {
struct tm timeinfo;
if(!getLocalTime(&timeinfo)) return String(millis());
char buffer[80];
strftime(buffer, sizeof(buffer), "%Y-%m-%d_%H-%M-%S", &timeinfo);
return String(buffer);
}
void handleRoot() {
String html = "<html><body><h1>ESP32 Camera</h1>";
html += "<p>Images captured: " + String(totalImagesCaptured) + "</p>";
html += "<p><a href='/status'>Status</a> | <a href='/download'>Download</a></p>";
html += "</body></html>";
server.send(200, "text/html", html);
}
void handleStatus() {
String json = "{\"available\":" + String(latestImage.available ? "true" : "false");
json += ",\"imageID\":" + String(latestImage.imageID);
json += ",\"totalCaptured\":" + String(totalImagesCaptured);
json += ",\"timestamp\":\"" + latestImage.timestamp + "\"";
json += ",\"buttonName\":\"" + latestImage.buttonName + "\"";
json += ",\"delayInfo\":\"" + latestImage.delayInfo + "\"}";
server.send(200, "application/json", json);
}
void handleLatest() {
if(latestImage.available && latestImage.buffer != NULL) {
server.send_P(200, "image/jpeg", (const char*)latestImage.buffer, latestImage.length);
} else {
server.send(404, "text/plain", "No image");
}
}
void handleDownload() {
if(latestImage.available && latestImage.buffer != NULL) {
String filename = latestImage.buttonName + "_" + latestImage.timestamp + ".jpg";
server.sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
server.send_P(200, "image/jpeg", (const char*)latestImage.buffer, latestImage.length);
} else {
server.send(404, "text/plain", "No image");
}
}
Upload Instructions
- Connect Xiao to computer via USB-C
- Open Arduino IDE, paste code, update WiFi credentials
- Verify PSRAM setting: Tools → PSRAM → "OPI PSRAM"
- Click Upload
- If upload fails: Hold BOOT, click Upload, release BOOT when dots appear
- After upload: Press RESET button
- Open Serial Monitor (115200 baud) to see IP address
Part 2: Python Download Script
Save as camera_downloader.py
Installation:
pip3 install --break-system-packages flask requests
Configuration: Update these three lines:
ESP32_IP = "192.168.72.72" # Your ESP32's IP from Serial Monitor
DOWNLOAD_FOLDER = "/path/to/your/images" # Where to save images
WEB_PORT = 8765 # Change if port conflicts
Click to view complete Python script (200 lines)
#!/usr/bin/env python3
import requests
import os
import time
from datetime import datetime
from flask import Flask, render_template_string, send_from_directory
import threading
ESP32_IP = "192.168.72.72"
DOWNLOAD_FOLDER = "/path/to/your/images"
CHECK_INTERVAL = 5
WEB_PORT = 8765
os.makedirs(DOWNLOAD_FOLDER, exist_ok=True)
last_image_id = 0
app = Flask(__name__)
GALLERY_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<title>Camera Feed</title>
<meta http-equiv="refresh" content="10">
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #fff; }
table { border-collapse: separate; border-spacing: 20px; width: 100%; table-layout: fixed; }
td { padding: 0; width: 33.33%; }
img { width: 100%; height: auto; display: block; }
.info { font-size: 14px; color: #666; padding: 10px 0; text-align: center; }
.delay { color: #999; font-size: 12px; }
</style>
</head>
<body>
{% if images %}
<table>
{% for row in images | batch(3) %}
<tr>
{% for image in row %}
<td>
<img src="/images/{{ image.filename }}" alt="Capture">
<div class="info">
{{ image.timestamp }}<br>
<span class="delay">{{ image.delay }}</span>
</div>
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
{% else %}
<p style="color: #666; text-align: center;">No images yet.</p>
{% endif %}
</body>
</html>
"""
def download_new_image():
global last_image_id
try:
response = requests.get(f"http://{ESP32_IP}/status", timeout=5)
if response.status_code != 200:
return False
data = response.json()
if not data['available']:
return False
current_id = data['imageID']
if current_id <= last_image_id:
return False
print(f"\n>>> New image detected! ID: {current_id}")
img_response = requests.get(f"http://{ESP32_IP}/latest", timeout=10)
if img_response.status_code == 200:
filename = f"{data['buttonName']}_{data['timestamp']}.jpg"
filepath = os.path.join(DOWNLOAD_FOLDER, filename)
with open(filepath, 'wb') as f:
f.write(img_response.content)
print(f"✓ Downloaded: {filename}")
last_image_id = current_id
return True
except Exception as e:
print(f"Error: {e}")
return False
def monitor_loop():
print(f"\n=== Monitoring ESP32 at {ESP32_IP} ===")
while True:
download_new_image()
time.sleep(CHECK_INTERVAL)
@app.route('/')
def gallery():
images = []
if os.path.exists(DOWNLOAD_FOLDER):
for filename in sorted(os.listdir(DOWNLOAD_FOLDER), reverse=True):
if filename.endswith('.jpg'):
parts = filename.replace('.jpg', '').split('_')
button = parts[0] if len(parts) > 0 else "Unknown"
delay = parts[1] if len(parts) > 1 else "Unknown"
timestamp = "Unknown"
if len(parts) >= 4:
timestamp = f"{parts[2]} {parts[3].replace('-', ':')}"
images.append({
'filename': filename,
'button': button,
'delay': delay,
'timestamp': timestamp
})
return render_template_string(GALLERY_TEMPLATE, images=images)
@app.route('/images/<filename>')
def serve_image(filename):
return send_from_directory(DOWNLOAD_FOLDER, filename)
if __name__ == '__main__':
print("=" * 50)
print("ESP32 Camera Downloader & Gallery")
print("=" * 50)
monitor_thread = threading.Thread(target=monitor_loop, daemon=True)
monitor_thread.start()
print(f"\n>>> Open http://localhost:{WEB_PORT} in browser <<<\n")
app.run(host='0.0.0.0', port=WEB_PORT, debug=False)
Running the System
# Navigate to project folder
cd ~/path/to/project
# Run script (keeps terminal open to see output)
python3 camera_downloader.py
# OR run in background
nohup python3 camera_downloader.py > camera_log.txt 2>&1 &
# Keep Mac awake (required for downloads)
caffeinate -d
Make it Public with ngrok
# Install ngrok
brew install ngrok
# Sign up at ngrok.com, then authenticate
ngrok authtoken YOUR_TOKEN
# Start tunnel (Python script must be running first!)
ngrok http 8765
# Share the https:// URL it provides!
Testing & Results
Functionality Validation
| Test |
Expected |
Actual |
Status |
| Button 1 (30 sec) |
Image at 30s |
30.2s |
✓ Pass |
| Button 2 (1 hour) |
Image at 1hr |
59m 58s |
✓ Pass |
| Button 3 (24 hours) |
Image at 24hr |
23h 59m 45s |
✓ Pass |
| Multiple concurrent |
All execute |
All 10 slots work |
✓ Pass |
| LED feedback |
Blink on press |
300ms consistent |
✓ Pass |
| Auto-download |
Within 10s |
5-7s average |
✓ Pass |
Performance Metrics
- Capture time: ~800ms from trigger to JPEG
- Download latency: 5-7 seconds (polling interval)
- Page load: <500ms for 20 images
- Memory usage: ~2MB (camera buffer + stored image)
- Power draw: 380mA during capture, 180mA idle
What Worked Well
- ✓ Camera integration reliable at SVGA resolution
- ✓ Multiple scheduling system handles 10 concurrent captures
- ✓ Python download automation works seamlessly
- ✓ Minimal web gallery loads fast, updates smoothly
- ✓ ngrok tunneling straightforward and stable
- ✓ Final PCB design (after iterations) functions perfectly
What Didn't Work
- ✗ SD card storage consistently failed initialization
- ✗ Battery power derailed by enamel-coated wires
- ✗ Periscope mechanism too complex for timeline
- ✗ First 8+ PCB designs had through-hole issues
- ✗ Wire-connected buttons ripped copper pads
Implications & Future
Photography Reimagined
This project reintroduces whimsy and spontaneity into digital photography by fundamentally changing our relationship with capture. Unlike traditional cameras where we consciously compose and perform for the lens, the delayed shutter creates a temporal gap between intention and documentation—we press a button and then forget, living naturally until the camera quietly observes us 30 seconds, an hour, or a day later.
This transforms photography from a performative act into ambient documentation, capturing moments as they actually unfold rather than how we wish to present them. The delays force us to surrender control, embracing imperfection and authenticity over curated perfection.
In an era dominated by endless selfies and carefully staged social media content, this camera asks: what if documentation was playful, unpredictable, and honest? What if we could see ourselves and our spaces as they truly are, not as we want them to appear? The result is a photography practice rooted in curiosity and chance rather than vanity and control—a small act of rebellion against the tyranny of the perfect shot.
Future Improvements
- Battery power done right: Use quality hookup wire and charge controller
- Onboard gallery: Host web interface directly on ESP32 using SPIFFS
- Cloud storage: Automatic upload to Google Photos or AWS S3
- Configurable delays: Web interface to set custom time intervals
- Image processing: Auto-exposure, timestamp overlays, motion detection
- Multiple cameras: Network of ESP32s reporting to central gallery
- Time-lapse compilation: Automatic video generation from 24hr sequences
Most Important Lesson: This project taught that iteration is the core of hardware development. Every "failure" was actually a step toward understanding—of tools, materials, processes, and design constraints. The final working device represents not just technical achievement but accumulated knowledge from dozens of mistakes.
References & Resources
Technical Documentation
- Seeed Studio: Xiao ESP32S3 Sense hardware specifications
- Espressif: ESP32 Camera library documentation
- Flask: Python web framework documentation
- ngrok: Secure tunneling service documentation
Assistance & Collaboration
- HTM community: Incredibly generous time through PCB iterations and design challenges
- Lab facilities: Access to PCB milling, soldering equipment, component inventory
- Claude AI: Code debugging, Python generation, CSS layouts, troubleshooting
- Open source community: ESP32 libraries, Flask framework, forum posts